SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
16.7 KB · 311 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { PageHeader, Section, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Badge, ClaimBadge, StatusBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';10import { YEAR_MIN, yearApprovalCounts, yearApprovals, yearEpidemiology, yearLiterature, yearTopSponsors, yearTrialTotals, yearTrialsByCancer, yearTrialsByPhase, yearsWithData } from '@/lib/queries/year';11import { fmtDate, fmtInt, fmtPct, humanize, phaseLabel, truncate } from '@/lib/format';1213export const revalidate = 3600;1415type Params = { year: string };1617function parseYear(raw: string): number | null {18  if (!/^\d{4}$/.test(raw)) return null;19  const y = Number(raw);20  const max = new Date().getUTCFullYear();21  return y >= YEAR_MIN && y <= max ? y : null;22}2324export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {25  const y = parseYear((await params).year);26  return y ? { title: `${y} in cancer`, description: `Oncology approvals, registered studies, research output and registry data of ${y}, from dated records in the index.`, alternates: { canonical: `/year/${y}` } } : { title: 'Year' };27}2829export default async function YearPage({ params }: { params: Promise<Params> }) {30  const year = parseYear((await params).year);31  if (year == null) notFound();32  const [years, approvalCounts, approvals, phases, totals, byCancer, sponsors, lit, epi] = await Promise.all([yearsWithData(), yearApprovalCounts(year), yearApprovals(year, 60), yearTrialsByPhase(year), yearTrialTotals(year), yearTrialsByCancer(year, 12), yearTopSponsors(year, 10), yearLiterature(year, 15), yearEpidemiology(year)]);33  const current = new Date().getUTCFullYear();34  const partial = year === current;35  const prev = years.find((y) => y < year);36  const next = [...years].reverse().find((y) => y > year);37  const totalApprovals = approvalCounts.reduce((s, r) => s + r.n, 0);3839  return (40    <div className="pb-8">41      <PageHeader kicker="Year in cancer" title={`${year} in cancer`} lede={`What the index holds for ${year}: regulatory decisions dated that year, studies first posted that year, PubMed output per cancer for that year and the registry observations whose reference year it is. Every figure is a count of dated source records — nothing is estimated${partial ? '; the current year is incomplete by construction' : ''}.`}>42        <nav aria-label="Years" className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">43          {prev ? (44            <Link href={`/year/${prev}`} className="ci-chip">45              ← {prev}46            </Link>47          ) : null}48          {next ? (49            <Link href={`/year/${next}`} className="ci-chip">50              {next} →51            </Link>52          ) : null}53          <span className="text-ink-3">Years with data: {years.length ? `${years[years.length - 1]}–${years[0]}` : 'none'}</span>54          <Link href="/pulse" className="ci-link">55            Last 30 days →56          </Link>57        </nav>58      </PageHeader>5960      {partial ? <Note tone="warn">{year} is still in progress: counts grow with each connector run and registries have not yet published {year} observations.</Note> : null}6162      <div className="mt-6 grid gap-10 lg:grid-cols-[1.35fr_1fr]">63        <div className="space-y-10">64          <Section id="approvals" kicker="Regulatory" title={`Approval records dated ${year} (${fmtInt(totalApprovals)})`} description="Approved, accelerated or conditional records by authority; original applications and records naming one cancer are listed first. One record is one decision for one application or product identifier.">65            {approvalCounts.length ? (66              <>67                <p className="mb-2 flex flex-wrap gap-1.5 text-[12px]">68                  {approvalCounts.map((r) => (69                    <Badge key={`${r.authority}-${r.jurisdiction}`} tone="outline" title={`${r.drugs} distinct drugs · ${r.with_cancer} records naming one cancer`}>70                      {r.authority} · {r.jurisdiction}: {fmtInt(r.n)} records / {fmtInt(r.drugs)} drugs71                    </Badge>72                  ))}73                </p>74                <ul className="divide-y divide-rule text-[13.5px]">75                  {approvals.map((a) => (76                    <li key={a.id} className="grid gap-x-3 py-2 sm:grid-cols-[92px_1fr]">77                      <span className="ci-mono text-[12px] text-ink-3">{fmtDate(a.approval_date)}</span>78                      <span className="min-w-0">79                        <Link href={`/drug/${a.drug_slug}`} className="ci-link font-medium">80                          {a.drug_name}81                        </Link>{' '}82                        <Badge tone="outline">83                          {a.authority} · {a.jurisdiction}84                        </Badge>{' '}85                        {a.approval_type ? <Badge tone={a.approval_type === 'ORIG' ? 'accent' : 'neutral'}>{a.approval_type === 'ORIG' ? 'original application' : a.approval_type === 'SUPPL' ? 'supplement' : a.approval_type}</Badge> : null}{' '}86                        {a.accelerated ? <Badge tone="warn">accelerated</Badge> : null}{' '}87                        <StatusBadge status={a.status} />{' '}88                        {a.cancer_slug ? (89                          <Link href={`/cancer/${a.cancer_slug}`} className="ci-link">90                            {a.cancer_name}91                          </Link>92                        ) : a.tumor_agnostic ? (93                          <Badge tone="accent">tumor-agnostic</Badge>94                        ) : (95                          <span className="text-ink-3">cancer not stated in this record</span>96                        )}97                        <span className="mt-0.5 block text-[12.5px] text-ink-2" title={a.indication}>98                          {truncate(a.indication, 160)}99                        </span>100                        <SourceBadge p={{ sourceSlug: a.source_slug }} compact />101                      </span>102                    </li>103                  ))}104                </ul>105                <p className="mt-2 flex items-center gap-2 text-[12px]">106                  <ClaimBadge kind="regulatory" />107                  <Link href={`/approvals?from=${year}&to=${year}`} className="ci-link">108                    All {year} records with filters →109                  </Link>110                </p>111              </>112            ) : (113              <EmptyState compact title={`No approval record dated ${year}`}>Approval records exist from the authorities ingested (FDA via openFDA, Health Canada, EMA); older decisions may be undated in the source.</EmptyState>114            )}115          </Section>116117          <Section id="trials" kicker="Clinical research" title={`Studies first posted in ${year}`} description="ClinicalTrials.gov oncology studies whose first-posted date falls in the year (all study types unless stated). Cancer attribution uses reconciled conditions over descendants; one study can count for several top-level cancers.">118            {totals ? (119              <>120                <dl className="mb-3 grid grid-cols-2 gap-x-6 gap-y-2 text-[13.5px] sm:grid-cols-3 lg:grid-cols-6">121                  {[122                    ['Studies', totals.total],123                    ['Interventional', totals.interventional],124                    ['Phase III', totals.phase3],125                    ['Industry-led', totals.industry],126                    ['With posted results', totals.with_results],127                    ['Countries with sites', totals.countries],128                  ].map(([k, v]) => (129                    <div key={String(k)}>130                      <dt className="ci-kicker">{k}</dt>131                      <dd className="ci-num text-left font-display text-2xl">{fmtInt(v as number)}</dd>132                    </div>133                  ))}134                </dl>135                <div className="grid gap-6 lg:grid-cols-2">136                  <div className="ci-table-wrap">137                    <table className="ci-table">138                      <caption className="pb-1 text-[12.5px] text-ink-3">By phase (a study with two phases counts in both)</caption>139                      <thead>140                        <tr>141                          <th>Phase</th>142                          <th className="num">Studies</th>143                          <th className="num">Interventional</th>144                          <th className="num">Industry share</th>145                        </tr>146                      </thead>147                      <tbody>148                        {phases.map((p) => (149                          <tr key={p.phase}>150                            <td>{p.phase === 'NA' ? 'Not applicable / not stated' : phaseLabel(p.phase)}</td>151                            <td className="num">{fmtInt(p.n)}</td>152                            <td className="num">{fmtInt(p.interventional)}</td>153                            <td className="num text-ink-3">{fmtPct(p.n ? p.industry / p.n : 0, 0)}</td>154                          </tr>155                        ))}156                      </tbody>157                    </table>158                  </div>159                  <div className="ci-table-wrap">160                    <table className="ci-table">161                      <caption className="pb-1 text-[12.5px] text-ink-3">Top-level cancers by studies (descendants included)</caption>162                      <thead>163                        <tr>164                          <th>Cancer</th>165                          <th className="num">Studies</th>166                          <th className="num">Phase III</th>167                        </tr>168                      </thead>169                      <tbody>170                        {byCancer.map((c) => (171                          <tr key={c.id}>172                            <td>173                              <Link href={`/cancer/${c.slug}/trials`} className="ci-link">174                                {c.canonical_name}175                              </Link>176                            </td>177                            <td className="num">{fmtInt(c.trials)}</td>178                            <td className="num">{fmtInt(c.phase3)}</td>179                          </tr>180                        ))}181                      </tbody>182                    </table>183                  </div>184                </div>185                <div className="mt-4 ci-table-wrap">186                  <table className="ci-table">187                    <caption className="pb-1 text-[12.5px] text-ink-3">Lead sponsors of interventional studies first posted in {year}</caption>188                    <thead>189                      <tr>190                        <th>Lead sponsor</th>191                        <th>Class</th>192                        <th className="num">Studies</th>193                        <th className="num">Phase III</th>194                      </tr>195                    </thead>196                    <tbody>197                      {sponsors.map((s) => (198                        <tr key={s.lead_sponsor}>199                          <td>200                            <Link href={`/trials?q=${encodeURIComponent(s.lead_sponsor)}`} className="ci-link">201                              {s.lead_sponsor}202                            </Link>203                          </td>204                          <td className="text-[12.5px] text-ink-3">{s.lead_sponsor_class ? humanize(s.lead_sponsor_class) : '—'}</td>205                          <td className="num">{fmtInt(s.n)}</td>206                          <td className="num">{fmtInt(s.phase3)}</td>207                        </tr>208                      ))}209                    </tbody>210                  </table>211                </div>212                <p className="mt-2 flex items-center gap-2 text-[12px]">213                  <ClaimBadge kind="published" /> <span className="text-ink-3">source: clinicaltrials · first_posted_date as posted</span>214                </p>215              </>216            ) : (217              <EmptyState compact title={`No study first posted in ${year} in the index`}>The ClinicalTrials.gov connector ingests oncology studies of every year; first-posted dates start in 1999 for most registrations.</EmptyState>218            )}219          </Section>220        </div>221222        <aside className="space-y-10">223          <Section id="literature" kicker="Research output" title={`PubMed records, ${year}`} description="Records matching each top-level cancer's stored PubMed query with a publication year equal to this year, with the previous year for comparison. A count of indexed records, not of scientific impact.">224            {lit.length ? (225              <>226                <div className="ci-table-wrap">227                  <table className="ci-table">228                    <thead>229                      <tr>230                        <th>Cancer</th>231                        <th className="num">{year}</th>232                        <th className="num">{year - 1}</th>233                        <th className="num">Change</th>234                      </tr>235                    </thead>236                    <tbody>237                      {lit.map((l) => {238                        const d = l.prev_count ? ((l.count - l.prev_count) / l.prev_count) * 100 : null;239                        return (240                          <tr key={l.id}>241                            <td>242                              <Link href={`/cancer/${l.slug}/research`} className="ci-link" title={l.query}>243                                {l.canonical_name}244                              </Link>245                            </td>246                            <td className="num">{fmtInt(l.count)}</td>247                            <td className="num text-ink-3">{l.prev_count == null ? '—' : fmtInt(l.prev_count)}</td>248                            <td className={`num ${d == null ? 'text-ink-4' : d > 0 ? 'text-ok' : d < 0 ? 'text-danger' : 'text-ink-3'}`}>{d == null ? '—' : `${d > 0 ? '+' : ''}${d.toFixed(0)} %`}</td>249                          </tr>250                        );251                      })}252                    </tbody>253                  </table>254                </div>255                <p className="mt-2 flex items-center gap-2 text-[12px]">256                  <ClaimBadge kind="computed" /> <span className="text-ink-3">literature_counts · window y{year} · query stored per row (hover a cancer)</span>257                </p>258                <Freshness dataUpdatedAt={lit[0]?.computed_at ?? null} extra="source: pubmed" />259              </>260            ) : (261              <EmptyState compact title={`No literature window for ${year}`}>Yearly PubMed windows are computed from 2016 onwards for entities with a stored query.</EmptyState>262            )}263          </Section>264265          <Section id="epidemiology" kicker="Registries" title={`Observations with reference year ${year}`} description="What registries have published for this year: observations by source, metric and geography. Registries publish with a lag of one to three years.">266            {epi.length ? (267              <div className="ci-table-wrap">268                <table className="ci-table">269                  <thead>270                    <tr>271                      <th>Geography</th>272                      <th>Metric</th>273                      <th>Source</th>274                      <th className="num">Observations</th>275                      <th className="num">Cancers</th>276                    </tr>277                  </thead>278                  <tbody>279                    {epi.map((e) => (280                      <tr key={`${e.geography_slug}-${e.metric}-${e.source_slug}`}>281                        <td>282                          <Link href={`/country/${e.geography_slug}?year=${year}`} className="ci-link">283                            {e.geography_name}284                          </Link>285                        </td>286                        <td className="text-[12.5px]">{EPI_METRIC_LABEL[e.metric] ?? humanize(e.metric)}</td>287                        <td>288                          <SourceBadge p={{ sourceSlug: e.source_slug, sourceName: e.source_name }} compact />289                        </td>290                        <td className="num">{fmtInt(e.n)}</td>291                        <td className="num">{fmtInt(e.cancers)}</td>292                      </tr>293                    ))}294                  </tbody>295                </table>296              </div>297            ) : (298              <EmptyState compact title={`No registry observation for ${year} yet`}>Registries publish with a lag; explore available years in the Data explorer.</EmptyState>299            )}300            <p className="mt-2 text-[12px]">301              <Link href={`/explore?from=${year}&to=${year}`} className="ci-link">302                Open {year} in the Data explorer →303              </Link>304            </p>305          </Section>306        </aside>307      </div>308    </div>309  );310}311